Micron Document
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
| SparkN0de-git | SparkN0de |
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------


Commit a9c6aa244ddf499c1cf205825c34316c391cc7ff


Parents : 95b90a7
Author : Ivan <ivan@quad4.io>
Signature : Invalid signer <e46112d44649266d71fe2193e00a4710>, author is <ivan@quad4.io>
Date : 2026-07-17T05:12:42-05:00

add and update tests

Changes
Diff

diff --git a/meshchatx.rsm b/meshchatx.rsm
index e04c1ee7..a9de527c 100644
Binary files a/meshchatx.rsm and b/meshchatx.rsm differ

diff --git a/tests/frontend/AppModals.test.js b/tests/frontend/AppModals.test.js
index 96e0b3b1..58041100 100644
--- a/tests/frontend/AppModals.test.js
+++ b/tests/frontend/AppModals.test.js
@@ -5,6 +5,11 @@ import { appPackageVersion } from "./fixtures/repoPackageVersion.js";
import { createRouter, createWebHashHistory } from "vue-router";
import { createI18n } from "vue-i18n";
import { createVuetify } from "vuetify";
+import { clearPromptSeenState } from "../../meshchatx/src/frontend/js/postInstallPromptState.js";
+import {
+ postInstallPromptRegistry,
+ registerPostInstallPrompt,
+} from "../../meshchatx/src/frontend/js/registries/postInstallPromptRegistry.js";
// Mock axios
const axiosMock = {
@@ -209,6 +214,81 @@ describe("App.vue Modals", () => {
expect(wrapper.vm.$refs.changelogModal.visible).toBe(true);
});
+ it("should show post-install prompt before changelog when one is pending", async () => {
+ clearPromptSeenState();
+ postInstallPromptRegistry.clear();
+ registerPostInstallPrompt({
+ id: "app_modal_test",
+ revision: 1,
+ titleKey: "app.name",
+ descriptionKey: "app.do_not_show_again",
+ primaryLabelKey: "common.close",
+ });
+
+ axiosMock.get.mockImplementation((url) => {
+ if (url === "/api/v1/app/info") {
+ return Promise.resolve({
+ data: {
+ app_info: {
+ version: appPackageVersion,
+ tutorial_seen: true,
+ changelog_seen_version: "3.9.0",
+ },
+ },
+ });
+ }
+ if (url === "/api/v1/app/changelog") {
+ return Promise.resolve({
+ data: { html: "<h1>New Features</h1>", version: appPackageVersion },
+ });
+ }
+ if (url === "/api/v1/config") return Promise.resolve({ data: { config: { theme: "dark" } } });
+ if (url === "/api/v1/auth/status") return Promise.resolve({ data: { auth_enabled: false } });
+ if (url === "/api/v1/blocked-destinations") return Promise.resolve({ data: { blocked_destinations: [] } });
+ if (url === "/api/v1/telephone/status") return Promise.resolve({ data: { active_call: null } });
+ if (url === "/api/v1/lxmf/propagation-node/status")
+ return Promise.resolve({ data: { propagation_node_status: { state: "idle" } } });
+ return Promise.resolve({ data: {} });
+ });
+
+ const wrapper = mount(App, {
+ global: {
+ plugins: [router, vuetify, i18n],
+ stubs: {
+ MaterialDesignIcon: true,
+ LxmfUserIcon: true,
+ LanguageSelector: true,
+ CallOverlay: true,
+ CommandPalette: true,
+ IntegrityWarningModal: true,
+ AndroidStorageChoicePrompt: true,
+ VDialog: true,
+ VCard: true,
+ VCardText: true,
+ VCardActions: true,
+ VBtn: true,
+ VIcon: true,
+ VToolbar: true,
+ VToolbarTitle: true,
+ VSpacer: true,
+ VProgressCircular: true,
+ VCheckbox: true,
+ VDivider: true,
+ },
+ },
+ });
+
+ await router.isReady();
+ await new Promise((resolve) => setTimeout(resolve, 250));
+
+ expect(wrapper.vm.$refs.postInstallPromptHost.visible).toBe(true);
+ expect(wrapper.vm.$refs.changelogModal.visible).toBe(false);
+
+ clearPromptSeenState();
+ postInstallPromptRegistry.clear();
+ wrapper.unmount();
+ });
+
it("playRingtone marks autoplay blocked on NotAllowedError", async () => {
const wrapper = mount(App, {
global: {

diff --git a/tests/frontend/NetworkVisualiser.test.js b/tests/frontend/NetworkVisualiser.test.js
index cceb9dd0..7d967cf5 100644
--- a/tests/frontend/NetworkVisualiser.test.js
+++ b/tests/frontend/NetworkVisualiser.test.js
@@ -531,4 +531,81 @@ describe("NetworkVisualiser.vue", () => {
expect(n.y).toBe(404);
expect(getPositions.mock.calls.length).toBeGreaterThanOrEqual(2);
});
+
+ it("display counts prefer webglEngine graph totals", async () => {
+ vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
+ const wrapper = mountVisualiser();
+ wrapper.vm.rendererMode = "webgl";
+ wrapper.vm.engineMode = "webgl";
+ wrapper.vm.graphNodeCount = 42;
+ wrapper.vm.graphEdgeCount = 17;
+ wrapper.vm.webglEngine = { destroy: vi.fn() };
+ expect(wrapper.vm.displayNodeCount).toBe(42);
+ expect(wrapper.vm.displayEdgeCount).toBe(17);
+ expect(wrapper.vm.hasRenderer).toBe(true);
+ });
+
+ it("runIconQueue updates webglEngine images instead of vis DataSet", async () => {
+ vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
+ const wrapper = mountVisualiser();
+ const updateNodeImages = vi.fn();
+ wrapper.vm.webglEngine = { updateNodeImages, destroy: vi.fn() };
+ wrapper.vm.rendererMode = "webgl";
+ wrapper.vm.engineMode = "webgl";
+ wrapper.vm.currentLOD = "high";
+ wrapper.vm.iconCache["account-#ffffff-#000000-64"] = "blob:custom";
+ wrapper.vm.iconQueue = [
+ {
+ nodeId: "peer1",
+ cacheKey: "account-#ffffff-#000000-64",
+ iconName: "account",
+ fg: "#ffffff",
+ bg: "#000000",
+ size: 64,
+ generation: wrapper.vm.iconQueueGeneration,
+ },
+ {
+ nodeId: "peer2",
+ cacheKey: "account-#ffffff-#000000-64",
+ iconName: "account",
+ fg: "#ffffff",
+ bg: "#000000",
+ size: 64,
+ generation: wrapper.vm.iconQueueGeneration,
+ },
+ ];
+ await wrapper.vm.runIconQueue();
+ expect(updateNodeImages).toHaveBeenCalledWith([
+ { id: "peer1", image: "blob:custom" },
+ { id: "peer2", image: "blob:custom" },
+ ]);
+ });
+
+ it("processVisualization on webgl path calls setGraph and schedules icons", async () => {
+ vi.spyOn(NetworkVisualiser.methods, "init").mockImplementation(() => {});
+ const wrapper = mountVisualiser();
+ const setGraph = vi.fn();
+ const getCounts = vi.fn().mockReturnValue({ nodes: 3, edges: 2 });
+ const getPositions = vi.fn().mockReturnValue({ me: { x: 0, y: 0 } });
+ const scheduleSpy = vi.spyOn(wrapper.vm, "scheduleIconQueue");
+ wrapper.vm.webglEngine = {
+ setGraph,
+ getCounts,
+ getPositions,
+ setLiveLayout: vi.fn(),
+ requestRedraw: vi.fn(),
+ destroy: vi.fn(),
+ };
+ wrapper.vm.rendererMode = "webgl";
+ wrapper.vm.engineMode = "webgl";
+ wrapper.vm.config = { display_name: "Me", identity_hash: "abc" };
+ wrapper.vm.interfaces = [{ name: "eth0", status: true, bitrate: 1000, txb: 0, rxb: 0 }];
+ wrapper.vm.pathTable = [];
+ wrapper.vm.announces = {};
+ await wrapper.vm.processVisualization();
+ expect(setGraph).toHaveBeenCalled();
+ expect(wrapper.vm.graphNodeCount).toBe(3);
+ expect(wrapper.vm.graphEdgeCount).toBe(2);
+ expect(scheduleSpy).toHaveBeenCalled();
+ });
});

diff --git a/tests/frontend/PostInstallPrompt.test.js b/tests/frontend/PostInstallPrompt.test.js
index 16e74f32..a1d1ce38 100644
--- a/tests/frontend/PostInstallPrompt.test.js
+++ b/tests/frontend/PostInstallPrompt.test.js
@@ -6,14 +6,19 @@ import { createI18n } from "vue-i18n";
import { createVuetify } from "vuetify";
import PostInstallPromptHost from "../../meshchatx/src/frontend/components/PostInstallPromptHost.vue";
import {
+ POST_INSTALL_PROMPTS_STORAGE_KEY,
clearPromptSeenState,
getSeenRevision,
markPromptSeen,
+ readSeenMap,
shouldShowPrompt,
+ writeSeenMap,
} from "../../meshchatx/src/frontend/js/postInstallPromptState.js";
import {
postInstallPromptRegistry,
registerPostInstallPrompt,
+ unregisterPostInstallPrompt,
+ listPostInstallPrompts,
listPostInstallPromptsByPriority,
} from "../../meshchatx/src/frontend/js/registries/postInstallPromptRegistry.js";
@@ -34,6 +39,12 @@ const i18n = createI18n({
});
const vuetify = createVuetify();
+function mountHost() {
+ return mount(PostInstallPromptHost, {
+ global: { plugins: [i18n, vuetify] },
+ });
+}
+
describe("postInstallPromptState", () => {
beforeEach(() => {
clearPromptSeenState();
@@ -52,6 +63,42 @@ describe("postInstallPromptState", () => {
markPromptSeen("demo", 2);
expect(shouldShowPrompt("demo", 2)).toBe(false);
});
+
+ it("does not lower a previously seen revision", () => {
+ markPromptSeen("demo", 5);
+ markPromptSeen("demo", 2);
+ expect(getSeenRevision("demo")).toBe(5);
+ expect(shouldShowPrompt("demo", 5)).toBe(false);
+ expect(shouldShowPrompt("demo", 6)).toBe(true);
+ });
+
+ it("ignores empty ids and non-positive revisions", () => {
+ expect(shouldShowPrompt("", 1)).toBe(false);
+ expect(shouldShowPrompt("demo", 0)).toBe(false);
+ expect(shouldShowPrompt("demo", -1)).toBe(false);
+ markPromptSeen("", 1);
+ expect(readSeenMap()).toEqual({});
+ });
+
+ it("round-trips the seen map through localStorage", () => {
+ writeSeenMap({ a: 1, b: 3 });
+ expect(readSeenMap()).toEqual({ a: 1, b: 3 });
+ expect(localStorage.getItem(POST_INSTALL_PROMPTS_STORAGE_KEY)).toContain('"b":3');
+ });
+
+ it("tolerates corrupt and non-object localStorage values", () => {
+ localStorage.setItem(POST_INSTALL_PROMPTS_STORAGE_KEY, "not-json");
+ expect(readSeenMap()).toEqual({});
+ localStorage.setItem(POST_INSTALL_PROMPTS_STORAGE_KEY, JSON.stringify(["x"]));
+ expect(readSeenMap()).toEqual({});
+ localStorage.setItem(POST_INSTALL_PROMPTS_STORAGE_KEY, JSON.stringify({ ok: 2, bad: "nope", neg: -1 }));
+ expect(readSeenMap()).toEqual({ ok: 2 });
+ });
+
+ it("floors fractional revisions when marking seen", () => {
+ markPromptSeen("demo", 2.9);
+ expect(getSeenRevision("demo")).toBe(2);
+ });
});
describe("postInstallPromptRegistry", () => {
@@ -87,9 +134,29 @@ describe("postInstallPromptRegistry", () => {
id: "bad",
revision: 0,
titleKey: "post_install.demo_title",
- })
+ }),
).toThrow(/revision/);
});
+
+ it("rejects missing id and titleKey", () => {
+ expect(() => registerPostInstallPrompt({ revision: 1, titleKey: "x" })).toThrow(/id/);
+ expect(() => registerPostInstallPrompt({ id: "no_title", revision: 1 })).toThrow(/titleKey/);
+ });
+
+ it("normalizes defaults and supports unregister", () => {
+ registerPostInstallPrompt({
+ id: "defaults",
+ revision: 2.7,
+ titleKey: "post_install.demo_title",
+ });
+ const entry = listPostInstallPrompts()[0];
+ expect(entry.revision).toBe(2);
+ expect(entry.priority).toBe(0);
+ expect(entry.dismissOnPrimary).toBe(true);
+ expect(entry.dismissOnSecondary).toBe(true);
+ unregisterPostInstallPrompt("defaults");
+ expect(listPostInstallPrompts()).toHaveLength(0);
+ });
});
describe("PostInstallPromptHost", () => {
@@ -121,13 +188,26 @@ describe("PostInstallPromptHost", () => {
primaryLabelKey: "post_install.demo_primary",
});
- const wrapper = mount(PostInstallPromptHost, {
- global: { plugins: [i18n, vuetify] },
- });
+ const wrapper = mountHost();
expect(await wrapper.vm.showNext()).toBe(true);
await wrapper.vm.$nextTick();
expect(wrapper.vm.visible).toBe(true);
expect(wrapper.vm.activeEntry?.id).toBe("high");
+ expect(wrapper.vm.resolvedTitle).toBe("Demo title");
+ expect(wrapper.vm.resolvedDescription).toBe("Demo body");
+ expect(wrapper.vm.resolvedPrimaryLabel).toBe("Got it");
+ });
+
+ it("showNext returns true without switching when already visible", async () => {
+ registerPostInstallPrompt({
+ id: "once",
+ revision: 1,
+ titleKey: "post_install.demo_title",
+ });
+ const wrapper = mountHost();
+ expect(await wrapper.vm.showNext()).toBe(true);
+ expect(await wrapper.vm.showNext()).toBe(true);
+ expect(wrapper.vm.activeEntry?.id).toBe("once");
});
it("primary dismisses and marks the revision seen", async () => {
@@ -140,15 +220,74 @@ describe("PostInstallPromptHost", () => {
onPrimary,
});
- const wrapper = mount(PostInstallPromptHost, {
- global: { plugins: [i18n, vuetify] },
- });
+ const wrapper = mountHost();
await wrapper.vm.showNext();
await wrapper.vm.onPrimary();
expect(onPrimary).toHaveBeenCalled();
expect(wrapper.vm.visible).toBe(false);
expect(getSeenRevision("once")).toBe(3);
expect(await wrapper.vm.showNext()).toBe(false);
+ expect(wrapper.emitted("completed")?.[0]?.[0]).toEqual({ id: "once", revision: 3 });
+ });
+
+ it("keeps the dialog open when onPrimary returns false", async () => {
+ registerPostInstallPrompt({
+ id: "keep",
+ revision: 1,
+ titleKey: "post_install.demo_title",
+ onPrimary: () => false,
+ });
+ const wrapper = mountHost();
+ await wrapper.vm.showNext();
+ await wrapper.vm.onPrimary();
+ expect(wrapper.vm.visible).toBe(true);
+ expect(getSeenRevision("keep")).toBe(0);
+ });
+
+ it("secondary dismisses when secondaryLabelKey is set", async () => {
+ const onSecondary = vi.fn();
+ registerPostInstallPrompt({
+ id: "two_btn",
+ revision: 1,
+ titleKey: "post_install.demo_title",
+ secondaryLabelKey: "post_install.demo_secondary",
+ onSecondary,
+ });
+ const wrapper = mountHost();
+ await wrapper.vm.showNext();
+ expect(wrapper.vm.resolvedSecondaryLabel).toBe("Later");
+ await wrapper.vm.onSecondary();
+ expect(onSecondary).toHaveBeenCalled();
+ expect(wrapper.vm.visible).toBe(false);
+ expect(getSeenRevision("two_btn")).toBe(1);
+ });
+
+ it("ignores secondary when no secondary label is configured", async () => {
+ registerPostInstallPrompt({
+ id: "primary_only",
+ revision: 1,
+ titleKey: "post_install.demo_title",
+ });
+ const wrapper = mountHost();
+ await wrapper.vm.showNext();
+ await wrapper.vm.onSecondary();
+ expect(wrapper.vm.visible).toBe(true);
+ expect(getSeenRevision("primary_only")).toBe(0);
+ });
+
+ it("does not mark seen when dismissOnPrimary is false", async () => {
+ registerPostInstallPrompt({
+ id: "no_dismiss",
+ revision: 1,
+ titleKey: "post_install.demo_title",
+ dismissOnPrimary: false,
+ });
+ const wrapper = mountHost();
+ await wrapper.vm.showNext();
+ await wrapper.vm.onPrimary();
+ expect(wrapper.vm.visible).toBe(false);
+ expect(getSeenRevision("no_dismiss")).toBe(0);
+ expect(wrapper.emitted("completed")?.[0]?.[0]).toEqual({ id: "no_dismiss", revision: 1 });
});
it("skips prompts when shouldShow returns false", async () => {
@@ -158,9 +297,66 @@ describe("PostInstallPromptHost", () => {
titleKey: "post_install.demo_title",
shouldShow: () => false,
});
- const wrapper = mount(PostInstallPromptHost, {
- global: { plugins: [i18n, vuetify] },
- });
+ const wrapper = mountHost();
expect(await wrapper.vm.showNext()).toBe(false);
});
+
+ it("skips prompts when shouldShow throws and continues to the next", async () => {
+ const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
+ registerPostInstallPrompt({
+ id: "broken",
+ revision: 1,
+ priority: 100,
+ titleKey: "post_install.demo_title",
+ shouldShow: () => {
+ throw new Error("boom");
+ },
+ });
+ registerPostInstallPrompt({
+ id: "ok",
+ revision: 1,
+ priority: 1,
+ titleKey: "post_install.demo_title",
+ });
+ const wrapper = mountHost();
+ expect(await wrapper.vm.showNext()).toBe(true);
+ expect(wrapper.vm.activeEntry?.id).toBe("ok");
+ errSpy.mockRestore();
+ });
+
+ it("awaits async shouldShow", async () => {
+ registerPostInstallPrompt({
+ id: "async_ok",
+ revision: 1,
+ titleKey: "post_install.demo_title",
+ shouldShow: async () => true,
+ });
+ const wrapper = mountHost();
+ expect(await wrapper.vm.showNext()).toBe(true);
+ expect(wrapper.vm.activeEntry?.id).toBe("async_ok");
+ });
+
+ it("emits dismissed when visibility is cleared", async () => {
+ registerPostInstallPrompt({
+ id: "dismiss_emit",
+ revision: 1,
+ titleKey: "post_install.demo_title",
+ });
+ const wrapper = mountHost();
+ await wrapper.vm.showNext();
+ wrapper.vm.onVisibleUpdate(false);
+ expect(wrapper.emitted("dismissed")).toBeTruthy();
+ expect(wrapper.vm.activeEntry).toBeNull();
+ });
+
+ it("defaults primary label to common.continue", async () => {
+ registerPostInstallPrompt({
+ id: "default_label",
+ revision: 1,
+ titleKey: "post_install.demo_title",
+ });
+ const wrapper = mountHost();
+ await wrapper.vm.showNext();
+ expect(wrapper.vm.resolvedPrimaryLabel).toBe("Continue");
+ });
});

diff --git a/tests/frontend/networkVisualiserWebGLEngine.test.js b/tests/frontend/networkVisualiserWebGLEngine.test.js
index 84f46c0b..4c540601 100644
--- a/tests/frontend/networkVisualiserWebGLEngine.test.js
+++ b/tests/frontend/networkVisualiserWebGLEngine.test.js
@@ -1,53 +1,166 @@
-import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import {
graphToSceneRequest,
isVisualiserWebGLSceneReady,
KIND_ME,
KIND_IFACE_ON,
+ KIND_IFACE_OFF,
KIND_PEER,
+ KIND_DISCOVERED,
pointerDistance,
pointerMidpoint,
+ canUseVisualiserWebGL,
+ createVisualiserWebGLEngine,
} from "@/js/networkVisualiserWebGLEngine.js";
import {
atlasUvForSlot,
mergeSceneNodesWithTextures,
SCENE_NODE_STRIDE,
NODE_STRIDE,
+ ATLAS_COLS,
+ ATLAS_ROWS,
+ tryCreateWebGL2Context,
} from "@/js/networkVisualiserWebGL.js";
-describe("networkVisualiserWebGLEngine", () => {
- const sceneFns = [
- "meshchatxVisualiserSceneSet",
- "meshchatxVisualiserSceneGetDrawBuffers",
- "meshchatxVisualiserSceneTick",
- "meshchatxVisualiserScenePick",
- "meshchatxVisualiserBuildPathGraph",
- "meshchatxVisualiserBuildFullGraph",
- "meshchatxVisualiserLayout",
- "meshchatxVisualiserPathHashes",
- "meshchatxVisualiserDedupeIcons",
- ];
+const SCENE_READY_FNS = [
+ "meshchatxVisualiserSceneSet",
+ "meshchatxVisualiserSceneGetDrawBuffers",
+ "meshchatxVisualiserSceneTick",
+ "meshchatxVisualiserScenePick",
+ "meshchatxVisualiserBuildPathGraph",
+ "meshchatxVisualiserBuildFullGraph",
+ "meshchatxVisualiserLayout",
+ "meshchatxVisualiserPathHashes",
+ "meshchatxVisualiserDedupeIcons",
+];
+
+function clearSceneGlobals() {
+ for (const name of SCENE_READY_FNS) {
+ delete globalThis[name];
+ }
+ delete globalThis.meshchatxVisualiserScenePanBy;
+ delete globalThis.meshchatxVisualiserSceneZoomAt;
+ delete globalThis.meshchatxVisualiserSceneDragStart;
+ delete globalThis.meshchatxVisualiserSceneDragTo;
+ delete globalThis.meshchatxVisualiserSceneDragEnd;
+ delete globalThis.meshchatxVisualiserSceneResize;
+ delete globalThis.meshchatxVisualiserSceneGetPositions;
+}
+
+function installSceneReadyStubs(overrides = {}) {
+ for (const name of SCENE_READY_FNS) {
+ globalThis[name] = overrides[name] || (() => null);
+ }
+}
+function stubGl() {
+ return {
+ createShader: () => ({}),
+ shaderSource: vi.fn(),
+ compileShader: vi.fn(),
+ getShaderParameter: () => true,
+ getShaderInfoLog: () => "",
+ deleteShader: vi.fn(),
+ createProgram: () => ({}),
+ attachShader: vi.fn(),
+ linkProgram: vi.fn(),
+ getProgramParameter: () => true,
+ getProgramInfoLog: () => "",
+ deleteProgram: vi.fn(),
+ createBuffer: () => ({}),
+ bindBuffer: vi.fn(),
+ bufferData: vi.fn(),
+ createVertexArray: () => ({}),
+ bindVertexArray: vi.fn(),
+ enableVertexAttribArray: vi.fn(),
+ vertexAttribPointer: vi.fn(),
+ vertexAttribDivisor: vi.fn(),
+ getUniformLocation: () => ({}),
+ createTexture: () => ({}),
+ bindTexture: vi.fn(),
+ texParameteri: vi.fn(),
+ texImage2D: vi.fn(),
+ texSubImage2D: vi.fn(),
+ pixelStorei: vi.fn(),
+ deleteTexture: vi.fn(),
+ deleteBuffer: vi.fn(),
+ deleteVertexArray: vi.fn(),
+ viewport: vi.fn(),
+ clearColor: vi.fn(),
+ clear: vi.fn(),
+ enable: vi.fn(),
+ blendFunc: vi.fn(),
+ useProgram: vi.fn(),
+ uniform2f: vi.fn(),
+ uniform1f: vi.fn(),
+ uniform1i: vi.fn(),
+ activeTexture: vi.fn(),
+ drawArrays: vi.fn(),
+ drawArraysInstanced: vi.fn(),
+ lineWidth: vi.fn(),
+ TEXTURE_2D: 0x0de1,
+ TEXTURE0: 0x84c0,
+ RGBA: 0x1908,
+ UNSIGNED_BYTE: 0x1401,
+ LINEAR: 0x2601,
+ CLAMP_TO_EDGE: 0x812f,
+ TEXTURE_MIN_FILTER: 0x2801,
+ TEXTURE_MAG_FILTER: 0x2800,
+ TEXTURE_WRAP_S: 0x2802,
+ TEXTURE_WRAP_T: 0x2803,
+ UNPACK_FLIP_Y_WEBGL: 0x9240,
+ COLOR_BUFFER_BIT: 0x4000,
+ BLEND: 0x0be2,
+ SRC_ALPHA: 0x0302,
+ ONE_MINUS_SRC_ALPHA: 0x0303,
+ ARRAY_BUFFER: 0x8892,
+ STATIC_DRAW: 0x88e4,
+ DYNAMIC_DRAW: 0x88e8,
+ FLOAT: 0x1406,
+ TRIANGLES: 0x0004,
+ LINES: 0x0001,
+ VERTEX_SHADER: 0x8b31,
+ FRAGMENT_SHADER: 0x8b30,
+ COMPILE_STATUS: 0x8b81,
+ LINK_STATUS: 0x8b82,
+ };
+}
+
+function makeCanvas(gl) {
+ const canvas = document.createElement("canvas");
+ Object.defineProperty(canvas, "clientWidth", { value: 400 });
+ Object.defineProperty(canvas, "clientHeight", { value: 300 });
+ canvas.getBoundingClientRect = () => ({ left: 10, top: 20, width: 400, height: 300 });
+ canvas.setPointerCapture = vi.fn();
+ canvas.releasePointerCapture = vi.fn();
+ vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(function (type) {
+ if (this === canvas && type === "webgl2") return gl;
+ if (type === "2d") return { clearRect: vi.fn(), drawImage: vi.fn() };
+ return null;
+ });
+ return canvas;
+}
+
+describe("networkVisualiserWebGLEngine", () => {
beforeEach(() => {
- for (const name of sceneFns) {
- delete globalThis[name];
- }
+ clearSceneGlobals();
});
afterEach(() => {
- for (const name of sceneFns) {
- delete globalThis[name];
- }
+ clearSceneGlobals();
+ vi.restoreAllMocks();
});
it("isVisualiserWebGLSceneReady requires scene exports", () => {
expect(isVisualiserWebGLSceneReady()).toBe(false);
- for (const name of sceneFns) {
- globalThis[name] = () => null;
- }
+ installSceneReadyStubs();
expect(isVisualiserWebGLSceneReady()).toBe(true);
});
+ it("canUseVisualiserWebGL is false without scene readiness", () => {
+ expect(canUseVisualiserWebGL()).toBe(false);
+ });
+
it("graphToSceneRequest maps me/iface/peer colors and kinds", () => {
const req = graphToSceneRequest(
[
@@ -76,19 +189,66 @@ describe("networkVisualiserWebGLEngine", () => {
expect(req.width).toBe(640);
});
+ it("graphToSceneRequest marks disconnected interfaces and discovered peers", () => {
+ const req = graphToSceneRequest(
+ [
+ {
+ id: "down",
+ group: "interface",
+ image: "/assets/images/network-visualiser/interface_disconnected.png",
+ color: { border: "#ef4444" },
+ },
+ { id: "disc", group: "discovered", color: "#a855f7" },
+ ],
+ [],
+ { width: 100, height: 100, zoom: 1 }
+ );
+ expect(req.nodes[0].kind).toBe(KIND_IFACE_OFF);
+ expect(req.nodes[1].kind).toBe(KIND_DISCOVERED);
+ expect(req.nodes[1].r).toBeCloseTo(168 / 255);
+ });
+
+ it("graphToSceneRequest skips incomplete edges and nodes", () => {
+ const req = graphToSceneRequest(
+ [{ id: "" }, { group: "me" }, { id: "ok", x: 1, y: 2 }],
+ [{ from: "a" }, { to: "b" }, { from: "ok", to: "ok" }],
+ { width: 10, height: 10 }
+ );
+ expect(req.nodes).toHaveLength(1);
+ expect(req.nodes[0].id).toBe("ok");
+ expect(req.edges).toHaveLength(1);
+ expect(req.zoom).toBe(1);
+ });
+
it("pointerDistance and midpoint support pinch zoom math", () => {
const a = { x: 0, y: 0 };
const b = { x: 30, y: 40 };
expect(pointerDistance(a, b)).toBe(50);
expect(pointerMidpoint(a, b)).toEqual({ x: 15, y: 20 });
});
+
+ it("pinch zoom factor is distance ratio around midpoint", () => {
+ const startA = { x: 100, y: 100 };
+ const startB = { x: 200, y: 100 };
+ const endA = { x: 80, y: 100 };
+ const endB = { x: 220, y: 100 };
+ const startDist = pointerDistance(startA, startB);
+ const endDist = pointerDistance(endA, endB);
+ expect(startDist).toBe(100);
+ expect(endDist).toBe(140);
+ expect(endDist / startDist).toBeCloseTo(1.4);
+ expect(pointerMidpoint(endA, endB)).toEqual({ x: 150, y: 100 });
+ });
});
describe("networkVisualiserWebGL textures", () => {
it("atlasUvForSlot maps grid cells", () => {
expect(atlasUvForSlot(0)).toEqual({ u: 0, v: 0 });
- expect(atlasUvForSlot(1).u).toBeCloseTo(1 / 16);
- expect(atlasUvForSlot(16).v).toBeCloseTo(1 / 16);
+ expect(atlasUvForSlot(1).u).toBeCloseTo(1 / ATLAS_COLS);
+ expect(atlasUvForSlot(ATLAS_COLS).v).toBeCloseTo(1 / ATLAS_ROWS);
+ const last = atlasUvForSlot(ATLAS_COLS * ATLAS_ROWS - 1);
+ expect(last.u).toBeCloseTo((ATLAS_COLS - 1) / ATLAS_COLS);
+ expect(last.v).toBeCloseTo((ATLAS_ROWS - 1) / ATLAS_ROWS);
});
it("mergeSceneNodesWithTextures attaches atlas UVs", () => {
@@ -115,4 +275,168 @@ describe("networkVisualiserWebGL textures", () => {
const out = mergeSceneNodesWithTextures(scene, [{ useTex: 0, u: 0, v: 0 }]);
expect(out[7]).toBe(0);
});
+
+ it("mergeSceneNodesWithTextures handles multiple nodes and reuses scratch", () => {
+ const scene = new Float32Array(SCENE_NODE_STRIDE * 2);
+ scene[0] = 1;
+ scene[SCENE_NODE_STRIDE] = 5;
+ const scratch = new Float32Array(NODE_STRIDE * 4);
+ const meta = [
+ { useTex: 1, u: 0.1, v: 0.2 },
+ { useTex: 0, u: 0, v: 0 },
+ ];
+ const out = mergeSceneNodesWithTextures(scene, meta, scratch);
+ expect(out.length).toBe(NODE_STRIDE * 2);
+ expect(out.buffer).toBe(scratch.buffer);
+ expect(out[0]).toBe(1);
+ expect(out[7]).toBe(1);
+ expect(out[NODE_STRIDE]).toBe(5);
+ expect(out[NODE_STRIDE + 7]).toBe(0);
+ });
+
+ it("mergeSceneNodesWithTextures tolerates empty input", () => {
+ expect(mergeSceneNodesWithTextures(null, []).length).toBe(0);
+ expect(mergeSceneNodesWithTextures(new Float32Array(0), []).length).toBe(0);
+ });
+
+ it("tryCreateWebGL2Context returns null for invalid canvas", () => {
+ expect(tryCreateWebGL2Context(null)).toBeNull();
+ expect(tryCreateWebGL2Context({})).toBeNull();
+ });
+});
+
+describe("createVisualiserWebGLEngine interactions", () => {
+ let engine;
+ let canvas;
+ let gl;
+ let zoomAt;
+
+ beforeEach(() => {
+ clearSceneGlobals();
+ zoomAt = vi.fn();
+ gl = stubGl();
+ installSceneReadyStubs({
+ meshchatxVisualiserSceneSet: () => JSON.stringify({ ok: true, nodes: 2, edges: 0 }),
+ meshchatxVisualiserSceneGetDrawBuffers: () => ({
+ ok: true,
+ nodes: new Float32Array(SCENE_NODE_STRIDE * 2),
+ edges: new Float32Array(0),
+ camX: 0,
+ camY: 0,
+ zoom: 1,
+ nodeCount: 2,
+ edgeCount: 0,
+ }),
+ meshchatxVisualiserScenePick: () => null,
+ });
+ globalThis.meshchatxVisualiserSceneZoomAt = (...args) => zoomAt(...args);
+ globalThis.meshchatxVisualiserScenePanBy = vi.fn();
+ globalThis.meshchatxVisualiserSceneGetPositions = () =>
+ JSON.stringify({ positions: { me: { x: 0, y: 0 } } });
+ globalThis.meshchatxVisualiserSceneResize = vi.fn();
+ canvas = makeCanvas(gl);
+ engine = createVisualiserWebGLEngine(canvas, {
+ getLiveLayout: () => false,
+ isDark: () => false,
+ });
+ });
+
+ afterEach(() => {
+ if (engine) {
+ engine.destroy();
+ engine = null;
+ }
+ clearSceneGlobals();
+ vi.restoreAllMocks();
+ });
+
+ it("throws without WASM scene readiness", () => {
+ engine.destroy();
+ engine = null;
+ clearSceneGlobals();
+ const c = makeCanvas(stubGl());
+ expect(() => createVisualiserWebGLEngine(c)).toThrow(/WASM scene unavailable/);
+ });
+
+ it("pinch gesture calls SceneZoomAt with distance ratio", () => {
+ const fire = (type, props) => {
+ const ev = new Event(type, { bubbles: true });
+ Object.assign(ev, props);
+ canvas.dispatchEvent(ev);
+ };
+
+ // css = client - rect.left/top => 110-10=100, 120-20=100
+ fire("pointerdown", { pointerId: 1, pointerType: "touch", button: 0, clientX: 110, clientY: 120 });
+ fire("pointerdown", { pointerId: 2, pointerType: "touch", button: 0, clientX: 210, clientY: 120 });
+ // dist 100 -> 140 (css x 100 and 240), mid css (170, 100)
+ fire("pointermove", { pointerId: 2, pointerType: "touch", button: 0, clientX: 250, clientY: 120 });
+
+ expect(zoomAt).toHaveBeenCalled();
+ const [sx, sy, factor] = zoomAt.mock.calls.at(-1);
+ expect(factor).toBeCloseTo(1.4, 5);
+ expect(sx).toBeCloseTo(170);
+ expect(sy).toBeCloseTo(100);
+ expect(globalThis.meshchatxVisualiserScenePanBy).not.toHaveBeenCalled();
+ });
+
+ it("wheel zoom calls SceneZoomAt", () => {
+ const ev = new Event("wheel", { bubbles: true, cancelable: true });
+ Object.assign(ev, { clientX: 60, clientY: 80, deltaY: -100 });
+ canvas.dispatchEvent(ev);
+ // css: 60-10=50, 80-20=60
+ expect(zoomAt).toHaveBeenCalledWith(50, 60, 1.12);
+ });
+
+ it("setGraph and updateNodeImages upload icon textures", async () => {
+ const OriginalImage = globalThis.Image;
+ globalThis.Image = class MockImage {
+ constructor() {
+ this.width = 32;
+ this.height = 32;
+ queueMicrotask(() => this.onload?.());
+ }
+ set src(_v) {
+ /* onload via microtask */
+ }
+ };
+
+ engine.setGraph(
+ [
+ {
+ id: "me",
+ group: "me",
+ image: "/assets/images/reticulum_logo_512.png",
+ x: 0,
+ y: 0,
+ },
+ {
+ id: "peer",
+ group: "announce",
+ image: "/assets/images/network-visualiser/user.png",
+ x: 10,
+ y: 10,
+ },
+ ],
+ [],
+ { preserveCamera: false, zoom: 1 }
+ );
+ expect(engine.getCounts()).toEqual({ nodes: 2, edges: 0 });
+ expect(engine.getPositions()).toEqual({ me: { x: 0, y: 0 } });
+
+ await vi.waitFor(() => {
+ expect(gl.texSubImage2D).toHaveBeenCalled();
+ });
+
+ const uploadsBefore = gl.texSubImage2D.mock.calls.length;
+ engine.updateNodeImages([{ id: "peer", image: "blob:custom-icon" }]);
+ await vi.waitFor(() => {
+ expect(gl.texSubImage2D.mock.calls.length).toBeGreaterThan(uploadsBefore);
+ });
+
+ globalThis.Image = OriginalImage;
+ });
+
+ it("sets touch-action none for mobile gestures", () => {
+ expect(canvas.style.touchAction).toBe("none");
+ });
});


──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────